@aexhq/sdk 0.40.7 → 0.40.9
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/dist/_contracts/asset-upload-helper.d.ts +38 -0
- package/dist/_contracts/asset-upload-helper.js +111 -0
- package/dist/_contracts/internal.d.ts +5 -9
- package/dist/_contracts/internal.js +5 -79
- package/dist/_contracts/operations.d.ts +9 -2
- package/dist/_contracts/operations.js +143 -16
- package/dist/_contracts/retry-core.d.ts +27 -0
- package/dist/_contracts/retry-core.js +75 -0
- package/dist/_contracts/runtime-types.d.ts +6 -0
- package/dist/asset-upload.d.ts +2 -1
- package/dist/asset-upload.js +28 -61
- package/dist/asset-upload.js.map +1 -1
- package/dist/cli.mjs +299 -80
- package/dist/cli.mjs.sha256 +1 -1
- package/dist/client.d.ts +5 -0
- package/dist/client.js +3 -2
- package/dist/client.js.map +1 -1
- package/dist/retry.js +13 -41
- package/dist/retry.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type RetryBackoffConfig } from "./retry-core.js";
|
|
2
|
+
export interface AssetUploadResponse {
|
|
3
|
+
readonly ok: boolean;
|
|
4
|
+
readonly status: number;
|
|
5
|
+
text(): Promise<string>;
|
|
6
|
+
readonly headers?: {
|
|
7
|
+
get(name: string): string | null;
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
/** Minimal fetch shape for direct-to-storage PUT requests. */
|
|
11
|
+
export type AssetFetch = (input: string, init?: RequestInit) => Promise<AssetUploadResponse>;
|
|
12
|
+
export declare const DIRECT_UPLOAD_MAX_ATTEMPTS = 3;
|
|
13
|
+
export declare const DIRECT_UPLOAD_INITIAL_DELAY_MS = 500;
|
|
14
|
+
export declare const DIRECT_UPLOAD_MAX_DELAY_MS = 5000;
|
|
15
|
+
export declare const DIRECT_UPLOAD_MAX_ELAPSED_MS = 30000;
|
|
16
|
+
export type AssetUploadSleep = (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
17
|
+
export interface AssetUploadRetryOptions {
|
|
18
|
+
readonly maxAttempts?: number;
|
|
19
|
+
readonly initialDelayMs?: number;
|
|
20
|
+
readonly maxDelayMs?: number;
|
|
21
|
+
readonly maxElapsedMs?: number;
|
|
22
|
+
readonly sleep?: AssetUploadSleep;
|
|
23
|
+
readonly random?: () => number;
|
|
24
|
+
readonly now?: () => number;
|
|
25
|
+
}
|
|
26
|
+
export interface ResolvedAssetUploadRetryConfig extends RetryBackoffConfig {
|
|
27
|
+
readonly maxAttempts: number;
|
|
28
|
+
readonly maxElapsedMs: number;
|
|
29
|
+
}
|
|
30
|
+
export declare function putDirectUploadWithRetry(fetchImpl: AssetFetch, uploadUrl: string, init: RequestInit, options?: AssetUploadRetryOptions): Promise<void>;
|
|
31
|
+
export declare function resolveAssetUploadRetryConfig(options?: AssetUploadRetryOptions): ResolvedAssetUploadRetryConfig;
|
|
32
|
+
export declare function directUploadRetryDelayMs(config: RetryBackoffConfig, attempt: number, random: () => number, retryAfterMs: number | undefined): number;
|
|
33
|
+
export declare function withinDirectUploadRetryBudget(config: Pick<ResolvedAssetUploadRetryConfig, "maxElapsedMs">, startedAt: number, delayMs: number, now: () => number): boolean;
|
|
34
|
+
export declare function isRetryableUploadStatus(status: number): boolean;
|
|
35
|
+
export declare function isRetryableUploadError(err: unknown): boolean;
|
|
36
|
+
export declare function directUploadNetworkError(uploadUrl: string, err: unknown, attempts: number): Error;
|
|
37
|
+
export declare function directUploadResponseError(uploadUrl: string, status: number, detail: string, attempts: number): Error;
|
|
38
|
+
export declare function sanitizeUploadText(text: string): string;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { extractErrorCode, redactUrl } from "./sdk-errors.js";
|
|
2
|
+
import { abortableSleep, computeRetryDelayMs, parseRetryAfterMs } from "./retry-core.js";
|
|
3
|
+
export const DIRECT_UPLOAD_MAX_ATTEMPTS = 3;
|
|
4
|
+
export const DIRECT_UPLOAD_INITIAL_DELAY_MS = 500;
|
|
5
|
+
export const DIRECT_UPLOAD_MAX_DELAY_MS = 5_000;
|
|
6
|
+
export const DIRECT_UPLOAD_MAX_ELAPSED_MS = 30_000;
|
|
7
|
+
export async function putDirectUploadWithRetry(fetchImpl, uploadUrl, init, options = {}) {
|
|
8
|
+
const config = resolveAssetUploadRetryConfig(options);
|
|
9
|
+
const sleep = options.sleep ?? abortableSleep;
|
|
10
|
+
const random = options.random ?? Math.random;
|
|
11
|
+
const now = options.now ?? Date.now;
|
|
12
|
+
const startedAt = now();
|
|
13
|
+
for (let attempt = 1; attempt <= config.maxAttempts; attempt++) {
|
|
14
|
+
let response;
|
|
15
|
+
try {
|
|
16
|
+
response = await fetchImpl(uploadUrl, init);
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
if (attempt < config.maxAttempts && isRetryableUploadError(err)) {
|
|
20
|
+
const delay = directUploadRetryDelayMs(config, attempt, random, undefined);
|
|
21
|
+
if (!withinDirectUploadRetryBudget(config, startedAt, delay, now)) {
|
|
22
|
+
throw directUploadNetworkError(uploadUrl, err, attempt);
|
|
23
|
+
}
|
|
24
|
+
await sleep(delay, init.signal ?? undefined);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
throw directUploadNetworkError(uploadUrl, err, attempt);
|
|
28
|
+
}
|
|
29
|
+
if (response.ok)
|
|
30
|
+
return;
|
|
31
|
+
const retryAfterMs = parseRetryAfterMs(response.headers?.get("retry-after"), now());
|
|
32
|
+
if (attempt < config.maxAttempts && isRetryableUploadStatus(response.status)) {
|
|
33
|
+
const delay = directUploadRetryDelayMs(config, attempt, random, retryAfterMs);
|
|
34
|
+
if (!withinDirectUploadRetryBudget(config, startedAt, delay, now)) {
|
|
35
|
+
const detail = await response.text().catch(() => "");
|
|
36
|
+
throw directUploadResponseError(uploadUrl, response.status, detail, attempt);
|
|
37
|
+
}
|
|
38
|
+
await response.text().catch(() => "");
|
|
39
|
+
await sleep(delay, init.signal ?? undefined);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const detail = await response.text().catch(() => "");
|
|
43
|
+
throw directUploadResponseError(uploadUrl, response.status, detail, attempt);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export function resolveAssetUploadRetryConfig(options = {}) {
|
|
47
|
+
const maxAttempts = Math.max(1, Math.floor(options.maxAttempts ?? DIRECT_UPLOAD_MAX_ATTEMPTS));
|
|
48
|
+
const initialDelayMs = Math.max(0, options.initialDelayMs ?? DIRECT_UPLOAD_INITIAL_DELAY_MS);
|
|
49
|
+
const maxDelayMs = Math.max(initialDelayMs, options.maxDelayMs ?? DIRECT_UPLOAD_MAX_DELAY_MS);
|
|
50
|
+
const maxElapsedMs = Math.max(0, options.maxElapsedMs ?? DIRECT_UPLOAD_MAX_ELAPSED_MS);
|
|
51
|
+
return { maxAttempts, initialDelayMs, maxDelayMs, maxElapsedMs };
|
|
52
|
+
}
|
|
53
|
+
export function directUploadRetryDelayMs(config, attempt, random, retryAfterMs) {
|
|
54
|
+
return computeRetryDelayMs(config, attempt, random, retryAfterMs);
|
|
55
|
+
}
|
|
56
|
+
export function withinDirectUploadRetryBudget(config, startedAt, delayMs, now) {
|
|
57
|
+
return now() - startedAt + delayMs <= config.maxElapsedMs;
|
|
58
|
+
}
|
|
59
|
+
export function isRetryableUploadStatus(status) {
|
|
60
|
+
return status === 408 || status === 425 || status === 429 || (status >= 500 && status <= 599);
|
|
61
|
+
}
|
|
62
|
+
export function isRetryableUploadError(err) {
|
|
63
|
+
if (isNamedError(err, "AbortError"))
|
|
64
|
+
return false;
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
export function directUploadNetworkError(uploadUrl, err, attempts) {
|
|
68
|
+
const safeUrl = redactUrl(uploadUrl);
|
|
69
|
+
const code = extractErrorCode(err);
|
|
70
|
+
const detail = sanitizeUploadText(errorMessage(err)).slice(0, 500);
|
|
71
|
+
return new Error(`uploadAsset: direct upload PUT failed for ${safeUrl} after ${attemptsLabel(attempts)}` +
|
|
72
|
+
(code ? ` (${code})` : "") +
|
|
73
|
+
(detail ? `: ${detail}` : ""));
|
|
74
|
+
}
|
|
75
|
+
export function directUploadResponseError(uploadUrl, status, detail, attempts) {
|
|
76
|
+
const safeUrl = redactUrl(uploadUrl);
|
|
77
|
+
const safeDetail = sanitizeUploadText(detail).slice(0, 500);
|
|
78
|
+
return new Error(`uploadAsset: direct upload PUT failed for ${safeUrl} with status ${status}` +
|
|
79
|
+
(attempts > 1 ? ` after ${attemptsLabel(attempts)}` : "") +
|
|
80
|
+
(safeDetail ? `: ${safeDetail}` : ""));
|
|
81
|
+
}
|
|
82
|
+
export function sanitizeUploadText(text) {
|
|
83
|
+
return text
|
|
84
|
+
.replace(/https?:\/\/[^\s<>"'`]+/g, (raw) => redactUrlPreservingTrailingPunctuation(raw))
|
|
85
|
+
.replace(/\b(?:X-Amz-(?:Algorithm|Credential|Date|Expires|Security-Token|Signature|SignedHeaders)|AWSAccessKeyId|Signature|Credential|Security-Token|AccessKeyId|SecretAccessKey|SessionToken)=([^&\s<>"'`]+)/gi, "[redacted]")
|
|
86
|
+
.replace(/\bAKIA[0-9A-Z]{8,}\b/g, "[redacted]");
|
|
87
|
+
}
|
|
88
|
+
function attemptsLabel(attempts) {
|
|
89
|
+
return attempts === 1 ? "1 attempt" : `${attempts} attempts`;
|
|
90
|
+
}
|
|
91
|
+
function errorMessage(err) {
|
|
92
|
+
if (err instanceof Error)
|
|
93
|
+
return err.message || err.name;
|
|
94
|
+
if (typeof err === "string")
|
|
95
|
+
return err;
|
|
96
|
+
return String(err);
|
|
97
|
+
}
|
|
98
|
+
function isNamedError(err, name) {
|
|
99
|
+
return stringProperty(err, "name") === name;
|
|
100
|
+
}
|
|
101
|
+
function stringProperty(value, key) {
|
|
102
|
+
if (!value || typeof value !== "object")
|
|
103
|
+
return undefined;
|
|
104
|
+
const prop = value[key];
|
|
105
|
+
return typeof prop === "string" && prop.length > 0 ? prop : undefined;
|
|
106
|
+
}
|
|
107
|
+
function redactUrlPreservingTrailingPunctuation(raw) {
|
|
108
|
+
const trailing = raw.match(/[),.;:!?]+$/)?.[0] ?? "";
|
|
109
|
+
const candidate = trailing ? raw.slice(0, -trailing.length) : raw;
|
|
110
|
+
return `${redactUrl(candidate)}${trailing}`;
|
|
111
|
+
}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
import { type AssetFetch, type AssetUploadRetryOptions } from "./asset-upload-helper.js";
|
|
1
2
|
export * from "./models.js";
|
|
2
3
|
export * from "./post-hook.js";
|
|
4
|
+
export * from "./retry-core.js";
|
|
3
5
|
export * from "./submission.js";
|
|
6
|
+
export { DIRECT_UPLOAD_MAX_ATTEMPTS, DIRECT_UPLOAD_INITIAL_DELAY_MS, DIRECT_UPLOAD_MAX_DELAY_MS, DIRECT_UPLOAD_MAX_ELAPSED_MS, directUploadRetryDelayMs, directUploadNetworkError, directUploadResponseError, isRetryableUploadError, isRetryableUploadStatus, putDirectUploadWithRetry, resolveAssetUploadRetryConfig, withinDirectUploadRetryBudget, sanitizeUploadText } from "./asset-upload-helper.js";
|
|
7
|
+
export type { AssetFetch, AssetUploadResponse, AssetUploadRetryOptions, AssetUploadSleep, ResolvedAssetUploadRetryConfig } from "./asset-upload-helper.js";
|
|
4
8
|
/**
|
|
5
9
|
* Subset of `HttpClient` needed by the asset uploader. Defined structurally so
|
|
6
10
|
* SDK and CLI tests can supply thin stubs without dragging in the full client.
|
|
@@ -8,15 +12,6 @@ export * from "./submission.js";
|
|
|
8
12
|
export interface AssetsHttpClient {
|
|
9
13
|
request<T>(path: string, init?: RequestInit, query?: Record<string, string>): Promise<T>;
|
|
10
14
|
}
|
|
11
|
-
/** Minimal fetch shape for the direct-to-storage PUT. */
|
|
12
|
-
export type AssetFetch = (input: string, init?: RequestInit) => Promise<{
|
|
13
|
-
readonly ok: boolean;
|
|
14
|
-
readonly status: number;
|
|
15
|
-
text(): Promise<string>;
|
|
16
|
-
readonly headers?: {
|
|
17
|
-
get(name: string): string | null;
|
|
18
|
-
};
|
|
19
|
-
}>;
|
|
20
15
|
export interface UploadAssetArgs {
|
|
21
16
|
readonly http: AssetsHttpClient;
|
|
22
17
|
readonly bytes: Uint8Array;
|
|
@@ -24,6 +19,7 @@ export interface UploadAssetArgs {
|
|
|
24
19
|
readonly hash: string;
|
|
25
20
|
readonly contentType?: string;
|
|
26
21
|
readonly fetch?: AssetFetch;
|
|
22
|
+
readonly retry?: AssetUploadRetryOptions;
|
|
27
23
|
}
|
|
28
24
|
export interface UploadedAsset {
|
|
29
25
|
readonly assetId: string;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { putDirectUploadWithRetry } from "./asset-upload-helper.js";
|
|
2
2
|
// Workspace-internal entry point. Re-exports submission building blocks and
|
|
3
3
|
// hosts small shared helpers so public packages can avoid hand-mirroring
|
|
4
4
|
// behavior. NOT part of the public `@aexhq/contracts` surface; do NOT add this
|
|
@@ -6,8 +6,9 @@ import { extractErrorCode, redactUrl } from "./sdk-errors.js";
|
|
|
6
6
|
// `@aexhq/contracts/internal` subpath.
|
|
7
7
|
export * from "./models.js";
|
|
8
8
|
export * from "./post-hook.js";
|
|
9
|
+
export * from "./retry-core.js";
|
|
9
10
|
export * from "./submission.js";
|
|
10
|
-
|
|
11
|
+
export { DIRECT_UPLOAD_MAX_ATTEMPTS, DIRECT_UPLOAD_INITIAL_DELAY_MS, DIRECT_UPLOAD_MAX_DELAY_MS, DIRECT_UPLOAD_MAX_ELAPSED_MS, directUploadRetryDelayMs, directUploadNetworkError, directUploadResponseError, isRetryableUploadError, isRetryableUploadStatus, putDirectUploadWithRetry, resolveAssetUploadRetryConfig, withinDirectUploadRetryBudget, sanitizeUploadText } from "./asset-upload-helper.js";
|
|
11
12
|
/**
|
|
12
13
|
* Upload `bytes` to the hosted API's content-addressable asset store via the
|
|
13
14
|
* direct-to-storage presign flow shared by SDK and CLI callers.
|
|
@@ -42,11 +43,11 @@ export async function uploadAsset(args) {
|
|
|
42
43
|
"content-type": args.contentType ?? "application/zip",
|
|
43
44
|
...(presign.requiredHeaders ?? {})
|
|
44
45
|
};
|
|
45
|
-
await
|
|
46
|
+
await putDirectUploadWithRetry(doFetch, presign.uploadUrl, {
|
|
46
47
|
method: "PUT",
|
|
47
48
|
headers: putHeaders,
|
|
48
49
|
body: args.bytes
|
|
49
|
-
});
|
|
50
|
+
}, args.retry);
|
|
50
51
|
const fin = await args.http.request("/assets/finalize", {
|
|
51
52
|
method: "POST",
|
|
52
53
|
headers: { "content-type": "application/json" },
|
|
@@ -73,81 +74,6 @@ function assetIdFromContentHash(contentHash) {
|
|
|
73
74
|
const hex = contentHash.startsWith("sha256:") ? contentHash.slice("sha256:".length) : contentHash;
|
|
74
75
|
return `asset_${hex}`;
|
|
75
76
|
}
|
|
76
|
-
async function putWithRetry(fetchImpl, uploadUrl, init) {
|
|
77
|
-
for (let attempt = 1; attempt <= DIRECT_UPLOAD_MAX_ATTEMPTS; attempt++) {
|
|
78
|
-
let response;
|
|
79
|
-
try {
|
|
80
|
-
response = await fetchImpl(uploadUrl, init);
|
|
81
|
-
}
|
|
82
|
-
catch (err) {
|
|
83
|
-
if (attempt < DIRECT_UPLOAD_MAX_ATTEMPTS && isRetryableUploadError(err)) {
|
|
84
|
-
continue;
|
|
85
|
-
}
|
|
86
|
-
throw directUploadNetworkError(uploadUrl, err, attempt);
|
|
87
|
-
}
|
|
88
|
-
if (response.ok)
|
|
89
|
-
return;
|
|
90
|
-
if (attempt < DIRECT_UPLOAD_MAX_ATTEMPTS && isRetryableUploadStatus(response.status)) {
|
|
91
|
-
await response.text().catch(() => "");
|
|
92
|
-
continue;
|
|
93
|
-
}
|
|
94
|
-
const detail = await response.text().catch(() => "");
|
|
95
|
-
throw directUploadResponseError(uploadUrl, response.status, detail, attempt);
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
function isRetryableUploadStatus(status) {
|
|
99
|
-
return status === 408 || status === 425 || status === 429 || (status >= 500 && status <= 599);
|
|
100
|
-
}
|
|
101
|
-
function isRetryableUploadError(err) {
|
|
102
|
-
if (isNamedError(err, "AbortError"))
|
|
103
|
-
return false;
|
|
104
|
-
return true;
|
|
105
|
-
}
|
|
106
|
-
function directUploadNetworkError(uploadUrl, err, attempts) {
|
|
107
|
-
const safeUrl = redactUrl(uploadUrl);
|
|
108
|
-
const code = extractErrorCode(err);
|
|
109
|
-
const detail = sanitizeUploadText(errorMessage(err)).slice(0, 500);
|
|
110
|
-
return new Error(`uploadAsset: direct upload PUT failed for ${safeUrl} after ${attemptsLabel(attempts)}` +
|
|
111
|
-
(code ? ` (${code})` : "") +
|
|
112
|
-
(detail ? `: ${detail}` : ""));
|
|
113
|
-
}
|
|
114
|
-
function directUploadResponseError(uploadUrl, status, detail, attempts) {
|
|
115
|
-
const safeUrl = redactUrl(uploadUrl);
|
|
116
|
-
const safeDetail = sanitizeUploadText(detail).slice(0, 500);
|
|
117
|
-
return new Error(`uploadAsset: direct upload PUT failed for ${safeUrl} with status ${status}` +
|
|
118
|
-
(attempts > 1 ? ` after ${attemptsLabel(attempts)}` : "") +
|
|
119
|
-
(safeDetail ? `: ${safeDetail}` : ""));
|
|
120
|
-
}
|
|
121
|
-
function attemptsLabel(attempts) {
|
|
122
|
-
return attempts === 1 ? "1 attempt" : `${attempts} attempts`;
|
|
123
|
-
}
|
|
124
|
-
function errorMessage(err) {
|
|
125
|
-
if (err instanceof Error)
|
|
126
|
-
return err.message || err.name;
|
|
127
|
-
if (typeof err === "string")
|
|
128
|
-
return err;
|
|
129
|
-
return String(err);
|
|
130
|
-
}
|
|
131
|
-
function isNamedError(err, name) {
|
|
132
|
-
return stringProperty(err, "name") === name;
|
|
133
|
-
}
|
|
134
|
-
function stringProperty(value, key) {
|
|
135
|
-
if (!value || typeof value !== "object")
|
|
136
|
-
return undefined;
|
|
137
|
-
const prop = value[key];
|
|
138
|
-
return typeof prop === "string" && prop.length > 0 ? prop : undefined;
|
|
139
|
-
}
|
|
140
|
-
function sanitizeUploadText(text) {
|
|
141
|
-
return text
|
|
142
|
-
.replace(/https?:\/\/[^\s<>"'`]+/g, (raw) => redactUrlPreservingTrailingPunctuation(raw))
|
|
143
|
-
.replace(/\b(?:X-Amz-(?:Algorithm|Credential|Date|Expires|Security-Token|Signature|SignedHeaders)|AWSAccessKeyId|Signature|Credential|Security-Token|AccessKeyId|SecretAccessKey|SessionToken)=([^&\s<>"'`]+)/gi, "[redacted]")
|
|
144
|
-
.replace(/\bAKIA[0-9A-Z]{8,}\b/g, "[redacted]");
|
|
145
|
-
}
|
|
146
|
-
function redactUrlPreservingTrailingPunctuation(raw) {
|
|
147
|
-
const trailing = raw.match(/[),.;:!?]+$/)?.[0] ?? "";
|
|
148
|
-
const candidate = trailing ? raw.slice(0, -trailing.length) : raw;
|
|
149
|
-
return `${redactUrl(candidate)}${trailing}`;
|
|
150
|
-
}
|
|
151
77
|
function bufferToHex(buffer) {
|
|
152
78
|
const view = new Uint8Array(buffer);
|
|
153
79
|
let out = "";
|
|
@@ -115,11 +115,18 @@ export declare function outputLink(http: HttpClient, runId: string, selectorOrQu
|
|
|
115
115
|
export declare function createOutputLink(http: HttpClient, runId: string, selectorOrQuery: OutputLinkSelector, options?: OutputLinkOptions): Promise<OutputLink>;
|
|
116
116
|
export declare function eventArchiveLink(http: HttpClient, runId: string, options?: OutputLinkOptions): Promise<OutputLink>;
|
|
117
117
|
export declare function resolveOutputFileSelector(outputs: readonly Output[], selector: OutputFileSelector, runId?: string): Output;
|
|
118
|
-
export declare function downloadOutput(http: HttpClient, runId: string, selector: OutputFileSelector): Promise<OutputFileDownload>;
|
|
118
|
+
export declare function downloadOutput(http: HttpClient, runId: string, selector: OutputFileSelector, options?: OutputTransferOptions): Promise<OutputFileDownload>;
|
|
119
119
|
/** Byte ceiling for {@link readOutputText} — a hard cap even if a caller asks for more. */
|
|
120
120
|
export declare const READ_OUTPUT_TEXT_MAX_BYTES = 10000000;
|
|
121
121
|
/** Default `maxBytes` for {@link readOutputText} — a chat-sized preview. */
|
|
122
122
|
export declare const READ_OUTPUT_TEXT_DEFAULT_BYTES = 50000;
|
|
123
|
+
/** Default per-attempt timeout while fetching or reading one output body. */
|
|
124
|
+
export declare const OUTPUT_FILE_TRANSFER_DEFAULT_TIMEOUT_MS = 15000;
|
|
125
|
+
/** Idempotent output GETs retry once on a transfer timeout. */
|
|
126
|
+
export declare const OUTPUT_FILE_TRANSFER_ATTEMPTS = 2;
|
|
127
|
+
export interface OutputTransferOptions {
|
|
128
|
+
readonly timeoutMs?: number;
|
|
129
|
+
}
|
|
123
130
|
/**
|
|
124
131
|
* Read ONE output file as byte-capped, decoded UTF-8 text. Built for handing a run
|
|
125
132
|
* deliverable to an LLM tool: it streams the file body and STOPS at `maxBytes`, so
|
|
@@ -214,7 +221,7 @@ export declare function download(http: HttpClient, runId: string): Promise<Uint8
|
|
|
214
221
|
* layout: `<rel>` per file plus a `manifest.json`
|
|
215
222
|
* (`{ runId, namespace: "outputs", outputs[], errors[] }`).
|
|
216
223
|
*/
|
|
217
|
-
export declare function downloadOutputs(http: HttpClient, runId: string): Promise<Uint8Array>;
|
|
224
|
+
export declare function downloadOutputs(http: HttpClient, runId: string, options?: OutputTransferOptions): Promise<Uint8Array>;
|
|
218
225
|
/**
|
|
219
226
|
* Download only the event archive (the `events` namespace). Always includes
|
|
220
227
|
* typed `events.jsonl`.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { strToU8, zipSync } from "fflate";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { normalizeRunUnit } from "./run-unit.js";
|
|
4
|
-
import { RunConfigValidationError, RunStateError } from "./sdk-errors.js";
|
|
4
|
+
import { AexNetworkError, RunConfigValidationError, RunStateError } from "./sdk-errors.js";
|
|
5
5
|
import { assertRunRecordArchivePublicSafeV1, buildRunRecordDownloadManifestV1 } from "./run-record.js";
|
|
6
6
|
/**
|
|
7
7
|
* The single source of truth for SDK<->BFF transport. The SDK class
|
|
@@ -369,17 +369,22 @@ export function resolveOutputFileSelector(outputs, selector, runId) {
|
|
|
369
369
|
}
|
|
370
370
|
return { ...selector, id: selector.id };
|
|
371
371
|
}
|
|
372
|
-
export async function downloadOutput(http, runId, selector) {
|
|
372
|
+
export async function downloadOutput(http, runId, selector, options) {
|
|
373
373
|
const output = isPathSelector(selector)
|
|
374
374
|
? resolveOutputFileSelector(await listOutputs(http, runId), selector, runId)
|
|
375
375
|
: resolveOutputFileSelector([], selector, runId);
|
|
376
|
-
const
|
|
377
|
-
|
|
376
|
+
const timeoutMs = normalizeOutputTransferTimeoutMs(options?.timeoutMs);
|
|
377
|
+
const path = `/api/runs/${encodeURIComponent(runId)}/outputs/${encodeURIComponent(output.id)}/download`;
|
|
378
|
+
return { output, bytes: await downloadOutputBytesWithRetry(http, path, timeoutMs) };
|
|
378
379
|
}
|
|
379
380
|
/** Byte ceiling for {@link readOutputText} — a hard cap even if a caller asks for more. */
|
|
380
381
|
export const READ_OUTPUT_TEXT_MAX_BYTES = 10_000_000;
|
|
381
382
|
/** Default `maxBytes` for {@link readOutputText} — a chat-sized preview. */
|
|
382
383
|
export const READ_OUTPUT_TEXT_DEFAULT_BYTES = 50_000;
|
|
384
|
+
/** Default per-attempt timeout while fetching or reading one output body. */
|
|
385
|
+
export const OUTPUT_FILE_TRANSFER_DEFAULT_TIMEOUT_MS = 15_000;
|
|
386
|
+
/** Idempotent output GETs retry once on a transfer timeout. */
|
|
387
|
+
export const OUTPUT_FILE_TRANSFER_ATTEMPTS = 2;
|
|
383
388
|
/**
|
|
384
389
|
* Read ONE output file as byte-capped, decoded UTF-8 text. Built for handing a run
|
|
385
390
|
* deliverable to an LLM tool: it streams the file body and STOPS at `maxBytes`, so
|
|
@@ -394,25 +399,142 @@ export async function readOutputText(http, runId, selector, options) {
|
|
|
394
399
|
const output = isPathSelector(selector)
|
|
395
400
|
? resolveOutputFileSelector(await listOutputs(http, runId), selector, runId)
|
|
396
401
|
: resolveOutputFileSelector([], selector, runId);
|
|
397
|
-
const
|
|
398
|
-
const
|
|
402
|
+
const timeoutMs = normalizeOutputTransferTimeoutMs(options?.timeoutMs);
|
|
403
|
+
const path = `/api/runs/${encodeURIComponent(runId)}/outputs/${encodeURIComponent(output.id)}/download`;
|
|
404
|
+
const capped = await readOutputTextWithRetry(http, path, maxBytes, timeoutMs);
|
|
399
405
|
const text = options?.grep === undefined ? capped.text : grepLines(capped.text, options.grep);
|
|
400
406
|
return { output, text, truncated: capped.truncated, totalBytes: capped.totalBytes };
|
|
401
407
|
}
|
|
408
|
+
async function downloadOutputBytesWithRetry(http, path, timeoutMs) {
|
|
409
|
+
return outputTransferWithRetry(path, timeoutMs, async () => {
|
|
410
|
+
const response = await downloadOutputResponse(http, path, timeoutMs);
|
|
411
|
+
return readResponseBytes(response, timeoutMs);
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
async function readOutputTextWithRetry(http, path, maxBytes, timeoutMs) {
|
|
415
|
+
return outputTransferWithRetry(path, timeoutMs, async () => {
|
|
416
|
+
const response = await downloadOutputResponse(http, path, timeoutMs);
|
|
417
|
+
return readCappedText(response, maxBytes, timeoutMs);
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
async function downloadOutputResponse(http, path, timeoutMs) {
|
|
421
|
+
const controller = new AbortController();
|
|
422
|
+
const { response } = await withOutputTransferTimeout(http.download(path, { signal: controller.signal }), timeoutMs, () => controller.abort(), "download-open");
|
|
423
|
+
return response;
|
|
424
|
+
}
|
|
425
|
+
async function outputTransferWithRetry(path, timeoutMs, action) {
|
|
426
|
+
const startedMs = Date.now();
|
|
427
|
+
let lastTimeout;
|
|
428
|
+
for (let attempt = 1; attempt <= OUTPUT_FILE_TRANSFER_ATTEMPTS; attempt += 1) {
|
|
429
|
+
try {
|
|
430
|
+
return await action();
|
|
431
|
+
}
|
|
432
|
+
catch (err) {
|
|
433
|
+
if (!(err instanceof OutputTransferTimeoutError))
|
|
434
|
+
throw err;
|
|
435
|
+
lastTimeout = err;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
throw new AexNetworkError({
|
|
439
|
+
method: "GET",
|
|
440
|
+
host: "",
|
|
441
|
+
path,
|
|
442
|
+
cause: lastTimeout ?? new OutputTransferTimeoutError("unknown", timeoutMs),
|
|
443
|
+
attempts: OUTPUT_FILE_TRANSFER_ATTEMPTS,
|
|
444
|
+
elapsedMs: Date.now() - startedMs
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
function normalizeOutputTransferTimeoutMs(value) {
|
|
448
|
+
if (value === undefined)
|
|
449
|
+
return OUTPUT_FILE_TRANSFER_DEFAULT_TIMEOUT_MS;
|
|
450
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
451
|
+
throw new RunConfigValidationError("outputs.download: timeoutMs must be a positive finite number", {
|
|
452
|
+
timeoutMs: value
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
return Math.max(1, Math.floor(value));
|
|
456
|
+
}
|
|
457
|
+
class OutputTransferTimeoutError extends Error {
|
|
458
|
+
code = "ETIMEDOUT";
|
|
459
|
+
phase;
|
|
460
|
+
constructor(phase, timeoutMs) {
|
|
461
|
+
super(`output transfer phase=${phase} timed out after ${timeoutMs}ms`);
|
|
462
|
+
this.name = "OutputTransferTimeoutError";
|
|
463
|
+
this.phase = phase;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
async function withOutputTransferTimeout(promise, timeoutMs, abort, phase) {
|
|
467
|
+
let timedOut = false;
|
|
468
|
+
let timeout;
|
|
469
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
470
|
+
timeout = setTimeout(() => {
|
|
471
|
+
timedOut = true;
|
|
472
|
+
reject(new OutputTransferTimeoutError(phase, timeoutMs));
|
|
473
|
+
queueMicrotask(() => {
|
|
474
|
+
try {
|
|
475
|
+
abort();
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
// Best effort: the timeout itself is the user-facing failure.
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
}, timeoutMs);
|
|
482
|
+
});
|
|
483
|
+
try {
|
|
484
|
+
return await Promise.race([promise, timeoutPromise]);
|
|
485
|
+
}
|
|
486
|
+
catch (err) {
|
|
487
|
+
if (timedOut && isAbortLikeError(err))
|
|
488
|
+
throw new OutputTransferTimeoutError(phase, timeoutMs);
|
|
489
|
+
throw err;
|
|
490
|
+
}
|
|
491
|
+
finally {
|
|
492
|
+
if (timeout !== undefined)
|
|
493
|
+
clearTimeout(timeout);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
function isAbortLikeError(err) {
|
|
497
|
+
const name = err?.name;
|
|
498
|
+
return name === "AbortError";
|
|
499
|
+
}
|
|
500
|
+
async function readResponseBytes(response, timeoutMs) {
|
|
501
|
+
const body = response.body;
|
|
502
|
+
if (!body) {
|
|
503
|
+
const buffer = await withOutputTransferTimeout(response.arrayBuffer(), timeoutMs, () => { }, "body-read");
|
|
504
|
+
return new Uint8Array(buffer);
|
|
505
|
+
}
|
|
506
|
+
const reader = body.getReader();
|
|
507
|
+
const chunks = [];
|
|
508
|
+
try {
|
|
509
|
+
while (true) {
|
|
510
|
+
const { done, value } = await withOutputTransferTimeout(reader.read(), timeoutMs, () => {
|
|
511
|
+
void reader.cancel().catch(() => { });
|
|
512
|
+
}, "body-read");
|
|
513
|
+
if (done)
|
|
514
|
+
break;
|
|
515
|
+
if (value && value.byteLength > 0)
|
|
516
|
+
chunks.push(value);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
finally {
|
|
520
|
+
void reader.cancel().catch(() => { });
|
|
521
|
+
}
|
|
522
|
+
return concatBytes(chunks);
|
|
523
|
+
}
|
|
402
524
|
/**
|
|
403
525
|
* Read a streamed response body up to `maxBytes`, decode as UTF-8, and report
|
|
404
526
|
* whether the file was larger than the cap. Prefers the `content-length` header
|
|
405
527
|
* for `totalBytes`; falls back to the bytes actually read. Cancels the stream
|
|
406
528
|
* once the cap is reached so the remainder is never transferred.
|
|
407
529
|
*/
|
|
408
|
-
async function readCappedText(response, maxBytes) {
|
|
530
|
+
async function readCappedText(response, maxBytes, timeoutMs) {
|
|
409
531
|
const declaredRaw = response.headers.get("content-length");
|
|
410
532
|
const declared = declaredRaw !== null && /^\d+$/.test(declaredRaw) ? Number(declaredRaw) : undefined;
|
|
411
533
|
const decoder = new TextDecoder("utf-8");
|
|
412
534
|
const body = response.body;
|
|
413
535
|
if (!body) {
|
|
414
536
|
// No streaming body (some fetch polyfills) — buffer, then slice to the cap.
|
|
415
|
-
const buf = new Uint8Array(await response.arrayBuffer());
|
|
537
|
+
const buf = new Uint8Array(await withOutputTransferTimeout(response.arrayBuffer(), timeoutMs, () => { }, "body-read"));
|
|
416
538
|
const total = declared ?? buf.byteLength;
|
|
417
539
|
return {
|
|
418
540
|
text: decoder.decode(buf.subarray(0, maxBytes)),
|
|
@@ -426,7 +548,9 @@ async function readCappedText(response, maxBytes) {
|
|
|
426
548
|
let sawMore = false;
|
|
427
549
|
try {
|
|
428
550
|
while (read < maxBytes) {
|
|
429
|
-
const { done, value } = await reader.read()
|
|
551
|
+
const { done, value } = await withOutputTransferTimeout(reader.read(), timeoutMs, () => {
|
|
552
|
+
void reader.cancel().catch(() => { });
|
|
553
|
+
}, "body-read");
|
|
430
554
|
if (done)
|
|
431
555
|
break;
|
|
432
556
|
if (value && value.byteLength > 0) {
|
|
@@ -436,13 +560,15 @@ async function readCappedText(response, maxBytes) {
|
|
|
436
560
|
}
|
|
437
561
|
if (read >= maxBytes) {
|
|
438
562
|
// We hit the cap; peek once more to learn whether bytes remain, then stop.
|
|
439
|
-
const next = await reader.read()
|
|
563
|
+
const next = await withOutputTransferTimeout(reader.read(), timeoutMs, () => {
|
|
564
|
+
void reader.cancel().catch(() => { });
|
|
565
|
+
}, "body-read");
|
|
440
566
|
if (!next.done && next.value && next.value.byteLength > 0)
|
|
441
567
|
sawMore = true;
|
|
442
568
|
}
|
|
443
569
|
}
|
|
444
570
|
finally {
|
|
445
|
-
|
|
571
|
+
void reader.cancel().catch(() => { });
|
|
446
572
|
}
|
|
447
573
|
const merged = concatBytes(chunks).subarray(0, maxBytes);
|
|
448
574
|
const truncated = declared !== undefined ? declared > maxBytes : read > maxBytes || sawMore;
|
|
@@ -578,17 +704,17 @@ export async function getWebhookSigningSecret(http) {
|
|
|
578
704
|
* surfaced (never silent) while a partially-available run still yields a
|
|
579
705
|
* usable zip.
|
|
580
706
|
*/
|
|
581
|
-
async function collectArtifactBytes(http, runId, items, zipPrefix, namespace) {
|
|
707
|
+
async function collectArtifactBytes(http, runId, items, zipPrefix, namespace, timeoutMs = OUTPUT_FILE_TRANSFER_DEFAULT_TIMEOUT_MS) {
|
|
582
708
|
const entries = [];
|
|
583
709
|
const captured = [];
|
|
584
710
|
const errors = [];
|
|
585
711
|
for (const item of items) {
|
|
586
712
|
const rel = item.filename ?? item.id;
|
|
587
713
|
try {
|
|
588
|
-
const
|
|
714
|
+
const path = `/api/runs/${encodeURIComponent(runId)}/${namespace}/${encodeURIComponent(item.id)}/download`;
|
|
589
715
|
entries.push({
|
|
590
716
|
path: `${zipPrefix}${rel}`,
|
|
591
|
-
bytes:
|
|
717
|
+
bytes: await downloadOutputBytesWithRetry(http, path, timeoutMs),
|
|
592
718
|
...(item.contentType !== undefined ? { contentType: item.contentType } : {}),
|
|
593
719
|
customerContent: true
|
|
594
720
|
});
|
|
@@ -843,9 +969,10 @@ export async function download(http, runId) {
|
|
|
843
969
|
* layout: `<rel>` per file plus a `manifest.json`
|
|
844
970
|
* (`{ runId, namespace: "outputs", outputs[], errors[] }`).
|
|
845
971
|
*/
|
|
846
|
-
export async function downloadOutputs(http, runId) {
|
|
972
|
+
export async function downloadOutputs(http, runId, options) {
|
|
847
973
|
const outputs = await listOutputs(http, runId);
|
|
848
|
-
const
|
|
974
|
+
const timeoutMs = normalizeOutputTransferTimeoutMs(options?.timeoutMs);
|
|
975
|
+
const { entries, captured, errors } = await collectArtifactBytes(http, runId, outputs, "", "outputs", timeoutMs);
|
|
849
976
|
return zipEntries([
|
|
850
977
|
...entries,
|
|
851
978
|
jsonEntry("manifest.json", { runId, namespace: "outputs", outputs: captured, errors })
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public-safe retry primitives shared by public packages through the
|
|
3
|
+
* `@aexhq/contracts/internal` subpath. Keep this module free of hosted
|
|
4
|
+
* implementation details and do not export it from the public contracts root.
|
|
5
|
+
*/
|
|
6
|
+
export declare const RETRYABLE_HTTP_STATUS: readonly number[];
|
|
7
|
+
export declare const RATE_LIMIT_HTTP_STATUS: readonly number[];
|
|
8
|
+
export interface RetryBackoffConfig {
|
|
9
|
+
readonly initialDelayMs: number;
|
|
10
|
+
readonly maxDelayMs: number;
|
|
11
|
+
}
|
|
12
|
+
export type RetryRandom = () => number;
|
|
13
|
+
export declare function isRetryableHttpStatus(status: number): boolean;
|
|
14
|
+
export declare function isRateLimitHttpStatus(status: number): boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Parse an HTTP `Retry-After` header into milliseconds. RFC 7231 permits either
|
|
17
|
+
* a non-negative integer number of seconds or an HTTP-date.
|
|
18
|
+
*/
|
|
19
|
+
export declare function parseRetryAfterMs(headerValue: string | null | undefined, now?: number): number | undefined;
|
|
20
|
+
/**
|
|
21
|
+
* Full-jitter exponential backoff: the nominal wait doubles per failed attempt
|
|
22
|
+
* and the actual delay is sampled uniformly in `[0, nominal]`.
|
|
23
|
+
*/
|
|
24
|
+
export declare function computeRetryBackoffDelayMs(config: RetryBackoffConfig, attemptNumber: number, random: RetryRandom): number;
|
|
25
|
+
/** Combine a server `Retry-After` floor with the jittered backoff delay. */
|
|
26
|
+
export declare function computeRetryDelayMs(config: RetryBackoffConfig, attemptNumber: number, random: RetryRandom, retryAfterMs: number | undefined): number;
|
|
27
|
+
export declare function abortableSleep(ms: number, signal?: AbortSignal): Promise<void>;
|